Перейти к основному содержимому

5.20. Основы языка

Разработчику Архитектору

Основы языка

Zig — это системный язык программирования, разработанный с целью стать простым, надёжным и эффективным инструментом для написания низкоуровневого кода. Он сочетает в себе контроль над ресурсами, присущий таким языкам, как C, с современными возможностями, направленными на повышение безопасности и читаемости. Zig не требует внешнего препроцессора, не использует макросы в традиционном смысле и не включает автоматическое управление памятью. Вместо этого он предоставляет разработчику явные механизмы управления временем жизни данных, выделения памяти и обработки ошибок.

Одна из ключевых целей Zig — устранение неопределённого поведения, характерного для C, за счёт строгой проверки на этапе компиляции и выполнения. Язык поддерживает кросс-компиляцию «из коробки», что делает его удобным для разработки под различные архитектуры без необходимости настройки сложных toolchain’ов. Zig также может использоваться как замена компилятору C, поскольку он включает в себя собственный LLVM-бэкенд и способен компилировать C-код напрямую.

Синтаксис и структура программы

Программа на Zig начинается с точки входа — функции main. Эта функция может быть определена без параметров или с аргументами командной строки. В отличие от C, где точка входа всегда имеет фиксированную сигнатуру, Zig позволяет гибко определять main в зависимости от потребностей.

Простейшая программа на Zig выглядит так:

const std = @import("std");

pub fn main() void {
std.debug.print("Привет, Zig!\n", .{});
}

Здесь директива @import("std") загружает стандартную библиотеку Zig. Ключевое слово const объявляет иммутабельную переменную std, через которую доступны модули стандартной библиотеки. Функция main объявлена как pub, что делает её видимой извне, и возвращает тип void, то есть ничего не возвращает. Вызов std.debug.print выводит строку в стандартный поток вывода. Второй аргумент — это кортеж (tuple), содержащий значения для подстановки в строку формата. В данном случае кортеж пуст, так как строка не содержит плейсхолдеров.

Типы данных

Zig является статически типизированным языком с выводом типов. Это означает, что тип каждой переменной известен на этапе компиляции, но разработчик не обязан указывать его явно, если компилятор может определить его по контексту.

Целочисленные типы

Zig предоставляет явные целочисленные типы с фиксированным размером. Например:

  • i8, i16, i32, i64, i128 — знаковые целые;
  • u8, u16, u32, u64, u128 — беззнаковые целые.

Также существуют специальные типы:

  • isize и usize — целые числа, размер которых соответствует разрядности системы (32 или 64 бита);
  • comptime_int — целое число, известное только на этапе компиляции.

Пример:

const x: i32 = 42;
const y = u8(255); // явное приведение типа

Во втором случае используется синтаксис вызова типа как функции — это безопасное приведение, которое проверяется на этапе компиляции или выполнения в зависимости от контекста.

Числа с плавающей точкой

Zig поддерживает два основных типа для чисел с плавающей точкой:

  • f32 — одинарная точность;
  • f64 — двойная точность.

Пример:

const pi: f64 = 3.141592653589793;
const e = 2.71828; // тип выводится как f64

Логический тип

Логический тип в Zig называется bool и принимает два значения: true и false.

const is_ready: bool = true;

Символы и строки

Символ в Zig представлен типом u8, так как язык использует UTF-8 для представления текста. Строка — это не примитивный тип, а указатель на последовательность байтов с нулевым завершением ([*:0]const u8) или срез ([]const u8).

Пример:

const message = "Здравствуй, мир!";

Здесь message имеет тип *const [15:0]u8 — указатель на массив из 15 байтов, завершающийся нулём. Однако чаще используют срезы:

const greeting: []const u8 = "Привет";

Строки в Zig иммутабельны по умолчанию. Для изменения содержимого требуется выделение памяти и работа с изменяемыми срезами.

Управление памятью

Zig не включает сборщик мусора. Вместо этого он предоставляет разработчику полный контроль над выделением и освобождением памяти через аллокаторы. Аллокатор — это структура, реализующая интерфейс выделения и освобождения памяти. Это позволяет легко подставлять разные стратегии: от простого arena-аллокатора до пулов памяти.

Пример выделения динамической строки:

const std = @import("std");

pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();

const buffer = try allocator.alloc(u8, 100);
defer allocator.free(buffer);

std.mem.copy(u8, buffer, "Динамическая строка");
std.debug.print("{s}\n", .{buffer});
}

Здесь используется GeneralPurposeAllocator — универсальный аллокатор из стандартной библиотеки. Ключевое слово defer гарантирует, что память будет освобождена при выходе из области видимости. Конструкция try обрабатывает возможные ошибки выделения памяти.

Обработка ошибок

Zig использует явную обработку ошибок через тип error. Ошибки — это перечислимые значения, которые могут быть объединены с другими типами с помощью оператора !.

Пример:

const FileError = error{
FileNotFound,
AccessDenied,
};

fn openFile(path: []const u8) FileError!void {
if (std.mem.eql(u8, path, "/forbidden")) {
return FileError.AccessDenied;
}
if (std.mem.eql(u8, path, "/missing")) {
return FileError.FileNotFound;
}
return;
}

pub fn main() void {
const result = openFile("/forbidden");
switch (result) {
FileError.AccessDenied => std.debug.print("Доступ запрещён\n", .{}),
FileError.FileNotFound => std.debug.print("Файл не найден\n", .{}),
else => std.debug.print("Файл открыт\n", .{}),
}
}

Тип FileError!void означает: «функция возвращает void или одну из ошибок из FileError». Обработка ошибок осуществляется через switch или с помощью try, catch.

Функции

Функции в Zig объявляются с помощью ключевого слова fn. Они могут быть generic’ами, если используют параметры времени компиляции.

Пример обобщённой функции:

fn max(a: anytype, b: anytype) @TypeOf(a, b) {
return if (a > b) a else b;
}

Здесь anytype означает, что аргумент может быть любого типа, совместимого с оператором сравнения. @TypeOf(a, b) возвращает общий тип аргументов, который становится возвращаемым типом функции.

Вызов:

const x = max(10, 20);      // x: comptime_int
const y = max(3.14, 2.71); // y: f64

Структуры и составные типы

Структуры в Zig объявляются с помощью ключевого слова struct. Они могут содержать поля, методы и даже реализовывать интерфейсы через трейты (comptime-полиморфизм).

Пример:

const Point = struct {
x: f64,
y: f64,

fn distanceFromOrigin(self: Point) f64 {
return std.math.sqrt(self.x * self.x + self.y * self.y);
}
};

pub fn main() void {
const p = Point{ .x = 3.0, .y = 4.0 };
std.debug.print("Расстояние: {d}\n", .{p.distanceFromOrigin()});
}

Метод distanceFromOrigin принимает self как первый аргумент — это соглашение Zig для методов экземпляра.

Компиляция и инструментарий

Zig поставляется со встроенным компилятором, линковщиком и менеджером зависимостей. Компиляция программы выполняется одной командой:

zig build-exe hello.zig

Проекты организуются через файл build.zig, который описывает сборку с помощью Zig-кода, а не внешних DSL. Это позволяет использовать всю выразительность языка для настройки сборки.

Пример минимального build.zig:

const std = @import("std");

pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});

const exe = b.addExecutable(.{
.name = "myapp",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});

b.installArtifact(exe);
}

Команда zig build запускает этот скрипт и собирает проект.


Компиляция во время выполнения (comptime)

Одной из самых выразительных возможностей Zig является поддержка вычислений на этапе компиляции через ключевое слово comptime. Оно указывает, что выражение или блок кода должен быть выполнен не во время запуска программы, а во время её сборки. Это позволяет генерировать структуры данных, проверять условия и даже создавать новые типы без потерь производительности в рантайме.

Пример простого использования:

fn factorial(n: u32) u32 {
if (n <= 1) return 1;
return n * factorial(n - 1);
}

pub fn main() void {
const compile_time_value = comptime factorial(5);
std.debug.print("Факториал 5 равен {d}\n", .{compile_time_value});
}

Здесь вызов factorial(5) происходит на этапе компиляции, и результат становится константой. Если передать в factorial значение, недоступное на этапе компиляции (например, введённое пользователем), компилятор выдаст ошибку.

Более сложный пример — создание массива с известным размером на этапе компиляции:

fn createArray(comptime size: usize, value: u8) [size]u8 {
var result: [size]u8 = undefined;
for (0..size) |i| {
result[i] = value;
}
return result;
}

pub fn main() void {
const buffer = createArray(10, 42);
std.debug.print("{any}\n", .{buffer});
}

Функция createArray принимает параметр size как comptime, что означает: «этот аргумент обязан быть известен при компиляции». В результате создаётся статический массив фиксированного размера, не требующий динамического выделения памяти.

Компиляция во времени компиляции также используется для реализации обобщённого программирования, метапрограммирования и генерации кода без макросов.

Работа с C-библиотеками

Zig предоставляет встроенную совместимость с C. Он может напрямую компилировать C-код, импортировать заголовочные файлы и вызывать функции из системных библиотек без необходимости внешних инструментов.

Для импорта C-заголовка используется директива @cImport:

const c = @cImport({
@cInclude("stdio.h");
});

pub fn main() void {
_ = c.printf("Привет из C!\n");
}

Компилятор Zig автоматически генерирует привязки к функциям, переменным и типам из заголовка. Это позволяет использовать существующие C-библиотеки так же естественно, как если бы они были написаны на Zig.

Если требуется собрать проект с внешней C-библиотекой, например, libcurl, достаточно указать её в build.zig:

const exe = b.addExecutable(.{
.name = "fetcher",
.root_source_file = b.path("src/main.zig"),
});
exe.linkLibC();
exe.linkSystemLibrary("curl");

После этого можно вызывать функции из curl напрямую:

const c = @cImport({
@cInclude("curl/curl.h");
});

pub fn main() !void {
_ = c.curl_global_init(c.CURL_GLOBAL_DEFAULT);
defer c.curl_global_cleanup();

const curl = c.curl_easy_init();
if (curl) |handle| {
_ = c.curl_easy_setopt(handle, c.CURLOPT_URL, "https://example.com");
_ = c.curl_easy_perform(handle);
c.curl_easy_cleanup(handle);
}
}

Такой подход делает Zig мощным инструментом для постепенной миграции C-проектов или для написания высокопроизводительных обёрток над системными API.

Безопасность и проверки на этапе выполнения

Zig стремится устранить неопределённое поведение, характерное для C. Для этого он вводит набор проверок, активных по умолчанию в режиме отладки (Debug). Эти проверки включают:

  • Контроль выхода за границы массива;
  • Проверку целочисленного переполнения;
  • Обнаружение использования неинициализированных переменных;
  • Валидацию указателей.

Пример:

pub fn main() void {
var arr = [_]u8{ 1, 2, 3 };
const index = 10;
_ = arr[index]; // Ошибка: индекс за пределами массива
}

В режиме Debug программа завершится с понятным сообщением об ошибке. В режиме ReleaseFast эти проверки отключаются ради производительности, но разработчик может явно включить их через @setRuntimeSafety(true).

Zig также не допускает неявных приведений между целочисленными типами разного размера или знаковости. Любое преобразование должно быть явным:

const a: u8 = 200;
// const b: i8 = a; // Ошибка компиляции
const b: i16 = @intCast(a); // Явное и безопасное преобразование

Функция @intCast проверяет, помещается ли значение в целевой тип. Если нет — возникает ошибка на этапе выполнения (в отладочном режиме) или неопределённое поведение (в релизе без проверок).

Практический пример: CLI-утилита для чтения файла

Рассмотрим небольшую утилиту командной строки, которая читает содержимое текстового файла и выводит его в терминал.

const std = @import("std");

fn readFile(allocator: std.mem.Allocator, path: []const u8) ![]u8 {
const file = try std.fs.cwd().openFile(path, .{});
defer file.close();

const stat = try file.stat();
const buffer = try allocator.alloc(u8, stat.size);
_ = try file.readAll(buffer);
return buffer;
}

pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();

const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);

if (args.len != 2) {
std.debug.print("Использование: catfile <путь_к_файлу>\n", .{});
return;
}

const content = try readFile(allocator, args[1]);
defer allocator.free(content);

std.io.getStdOut().writer().writeAll(content) catch {};
}

Эта программа:

  • Использует GeneralPurposeAllocator для управления памятью;
  • Читает аргументы командной строки;
  • Открывает файл, выделяет буфер нужного размера и читает всё содержимое;
  • Выводит данные в стандартный поток.

Обратите внимание на отсутствие глобального состояния, явное управление ресурсами и чёткую обработку ошибок. Каждый шаг контролируется разработчиком, а компилятор помогает избежать распространённых ошибок.


Система модулей и организация кода

Zig использует иерархическую систему модулей, основанную на файловой структуре проекта. Каждый файл .zig представляет собой модуль, который может экспортировать функции, типы, константы и другие сущности через ключевое слово pub. Импорт модуля осуществляется с помощью встроенной функции @import, которая принимает путь к файлу относительно корня проекта или стандартной библиотеки.

Рассмотрим типичную структуру проекта:

my_project/
├── build.zig
└── src/
├── main.zig
└── utils.zig

Файл src/utils.zig может содержать вспомогательные функции:

// src/utils.zig
pub fn greet(name: []const u8) void {
std.debug.print("Привет, {s}!\n", .{name});
}

pub const version = "1.0.0";

В main.zig этот модуль импортируется так:

// src/main.zig
const std = @import("std");
const utils = @import("utils.zig");

pub fn main() void {
utils.greet("Zig");
std.debug.print("Версия: {s}\n", .{utils.version});
}

Обратите внимание: @import("std") — это особый случай, указывающий на встроенную стандартную библиотеку. Для пользовательских модулей указывается относительный путь в виде строки.

Zig не требует объявления «пакетов» или манифестов. Всё управление зависимостями происходит через build.zig, где можно подключать внешние репозитории как зависимости:

const deps = b.dependency("some_lib", .{
.version = "1.2.3",
});
exe.root_module.addImport("some_lib", deps.module("some_lib"));

После этого в коде можно писать:

const some_lib = @import("some_lib");

Такой подход обеспечивает полную прозрачность зависимостей и избавляет от необходимости в отдельных менеджерах пакетов.

Тестирование и документирование внутри кода

Zig интегрирует тестирование непосредственно в язык. Любой блок кода может содержать тесты, помеченные ключевым словом test. Тесты компилируются только при запуске в режиме тестирования и не влияют на производственный код.

Пример:

fn add(a: i32, b: i32) i32 {
return a + b;
}

test "сложение положительных чисел" {
try std.testing.expect(add(2, 3) == 5);
}

test "сложение с нулём" {
try std.testing.expect(add(0, 42) == 42);
}

Запуск тестов выполняется командой:

zig test src/main.zig

Компилятор автоматически находит все блоки test и выполняет их. Функция std.testing.expect проверяет условие и завершает тест с ошибкой, если оно ложно.

Документация в Zig также встраивается в код. Комментарии перед объявлением становятся частью генерируемой документации, если использовать инструмент zig doc:

/// Складывает два целых числа.
/// Поддерживает отрицательные значения и переполнение (в ReleaseFast — без проверок).
pub fn add(a: i32, b: i32) i32 {
return a + b;
}

Команда zig doc src/main.zig сгенерирует HTML-документацию с описанием функции, её параметров и примерами использования (если они указаны).

Эта модель поощряет сопровождение кода и документации в одном месте, исключая рассинхронизацию между реализацией и описанием.

Асинхронное программирование

Zig поддерживает асинхронное выполнение через механизм async/await, реализованный на уровне языка без использования потоков или обратных вызовов. Асинхронная функция возвращает фрейм — структуру, содержащую состояние выполнения. Оператор await приостанавливает текущую корутину до завершения другой.

Пример простого асинхронного таймера:

const std = @import("std");

fn delay(ms: u64) void {
std.time.sleep(std.time.ns_per_ms * ms);
}

fn asyncTask() !void {
std.debug.print("Начало задачи\n", .{});
delay(1000);
std.debug.print("Задача завершена\n", .{});
}

pub fn main() !void {
const frame = async asyncTask();
await frame;
}

Здесь async asyncTask() создаёт фрейм выполнения, но не запускает функцию немедленно. Вызов await frame передаёт управление внутрь asyncTask, которая выполняется в том же потоке. Это позволяет писать асинхронный код без гонок данных и сложной синхронизации.

Zig не предоставляет встроенный event loop, но его легко реализовать поверх примитивов языка. Например, можно создать диспетчер задач, который управляет очередью фреймов и выполняет их по расписанию.

Асинхронность в Zig особенно эффективна для I/O-bound задач, таких как сетевые запросы или работа с файлами, где паузы ввода-вывода могут использоваться для выполнения других операций.

Написание кросс-платформенных приложений

Одна из сильных сторон Zig — встроенная поддержка кросс-компиляции. Компилятор содержит toolchain’и для всех основных платформ: Windows, Linux, macOS, FreeBSD, а также для встраиваемых систем (ARM, RISC-V и другие). Чтобы собрать программу под другую архитектуру, достаточно указать цель:

zig build-exe main.zig --target x86_64-windows-gnu
zig build-exe main.zig --target aarch64-linux-musl

Никаких дополнительных установок не требуется. Zig поставляется со всеми необходимыми заголовками и библиотеками.

Условная компиляция достигается через параметры времени компиляции:

pub fn main() void {
if (@import("builtin").target.os.tag == .windows) {
std.debug.print("Запущено на Windows\n", .{});
} else if (@import("builtin").target.os.tag == .linux) {
std.debug.print("Запущено на Linux\n", .{});
}
}

Модуль builtin предоставляет информацию о текущей цели компиляции: операционной системе, архитектуре, ABI и других параметрах. Это позволяет писать платформенно-зависимый код без внешних макросов или препроцессоров.

Сравнение с другими системными языками

Zig занимает уникальное положение среди системных языков. По сравнению с C, он устраняет источники неопределённого поведения, добавляет безопасные приведения типов, встроенную обработку ошибок и современный инструментарий. При этом он сохраняет ту же модель памяти и производительность.

По сравнению с Rust, Zig отказывается от сложной системы владения и заимствования. Вместо этого он даёт разработчику полный контроль над временем жизни объектов, полагаясь на дисциплину и явное управление. Это делает Zig проще для освоения тем, кто знаком с C, но требует большей ответственности от программиста.

В отличие от C++, Zig не включает шаблоны, исключения, виртуальные таблицы или множественное наследование. Он заменяет эти механизмы более простыми и предсказуемыми конструкциями: comptime-вычислениями, явной передачей интерфейсов и композицией.

Zig — это не попытка создать «лучший язык», а стремление сделать системное программирование более надёжным, понятным и доступным без жертв в производительности или контроле.